Перейти к основному содержимому

5.06. ООП в C++

Разработчику Архитектору

ООП в C++

Пример класса в C++

#include <iostream>
#include <string>

class Unit {
public:
std::string name;
int intel;
int agility;
int strength;
int health;
int mana;
int level;

Unit() {
name = "Имя";
intel = 10;
agility = 10;
strength = 10;
health = 100;
mana = 50;
level = 1;
}

int getDamage() const {
return (intel + agility + strength) + (level * 2);
}

void attack(Unit& target) {
std::cout << name << " атакует " << target.name << " и наносит " << getDamage() << " единиц урона." << std::endl;
target.health -= getDamage();
std::cout << target.name << " теперь имеет " << target.health << " здоровья." << std::endl;
}
};

int main() {
Unit warrior;
warrior.name = "Воин";
warrior.intel = 5;
warrior.agility = 15;
warrior.strength = 30;

Unit mage;
mage.name = "Маг";
mage.intel = 35;
mage.agility = 10;
mage.strength = 5;

warrior.attack(mage);
mage.attack(warrior);

return 0;
}

Директива #include <iostream> подключает библиотеку потокового ввода-вывода. Директива #include <string> подключает библиотеку для работы со строками стандартной библиотеки C++. Эти директивы размещаются в начале исходного файла и обеспечивают доступ к необходимым компонентам языка.

Ключевое слово class определяет новый пользовательский тип данных. Блок класса ограничен фигурными скобками и завершается точкой с запятой. Модификатор доступа public предоставляет внешнему коду прямой доступ к полям и методам, объявленным после этого ключевого слова.

Поля класса объявляются с указанием типа и имени. Тип std::string представляет строку текста из стандартной библиотеки. Тип int хранит целочисленные значения. Каждое поле принадлежит конкретному экземпляру класса и содержит независимое значение.

Метод с именем класса и отсутствием возвращаемого типа представляет конструктор. Конструктор вызывается автоматически при создании объекта оператором. В конструкторе устанавливаются начальные значения всех полей объекта. Конструктор без параметров называется конструктором по умолчанию.

Метод getDamage объявлен с модификатором const, что гарантирует отсутствие изменения состояния объекта при его вызове. Метод возвращает целочисленное значение, вычисленное на основе текущих характеристик персонажа. Каждый вызов метода производит актуальный расчёт урона.

Метод attack принимает ссылку на другой объект класса Unit. Использование ссылки вместо копирования объекта повышает эффективность и позволяет изменять состояние целевого объекта напрямую. Внутри метода происходит вывод сообщения и модификация здоровья цели через операцию вычитания.

Функция main служит точкой входа в программу на C++. Возвращаемое значение типа int сообщает операционной системе о результате выполнения программы. Код возврата 0 означает успешное завершение. Все исполняемые инструкции программы размещаются внутри тела этой функции.

Оператор объявления переменного типа Unit вызывает конструктор класса и создаёт новый объект в памяти. После создания объекта значения его полей изменяются через оператор присваивания и точечную нотацию. Каждый объект хранит собственный набор значений полей независимо от других экземпляров.

Объект std::cout представляет стандартный поток вывода. Оператор << передаёт данные в поток вывода. Константа std::endl вставляет символ новой строки и сбрасывает буфер вывода. Последовательное применение оператора << формирует составное сообщение из нескольких частей.

Исходный файл с расширением .cpp компилируется в исполняемый файл с помощью компилятора C++. Команда g++ filename.cpp -o program создаёт исполняемый файл program. Запуск программы выполняется командой ./program в терминале операционной системы. Программа выводит последовательность сообщений о взаимодействии объектов в консоль.


Объектно-ориентированное программирование

Объектно-ориентированное программирование (ООП) — это парадигма, в которой программа строится вокруг объектов, объединяющих данные и поведение, с которыми эти данные связаны. Эта модель позволяет проектировать программы в терминах предметной области, повышая читаемость, сопровождаемость и масштабируемость кода.

C++ — один из немногих языков, в которых ООП не навязано в ущерб другим парадигмам, а встроено органично в многоуровневую систему абстракций. Он поддерживает объектную, процедурную, обобщённую, функциональную и даже метапрограммную парадигмы. Такой подход позволяет применять ООП там, где он действительно оправдан: для моделирования сложных сущностей, их взаимодействий и иерархий, — и при этом не платить избыточной стоимостью абстракций в критичных по производительности участках кода.

В языках вроде Java или C# объектная модель доминирует: почти всё — объект, даже примитивы обёрнуты в классы-оболочки. C++ же начинается с базовых типов и позволяет постепенно и избирательно повышать уровень абстракции: от свободных функций и структур до полноценных иерархий классов с виртуальными диспетчеризациями, шаблонными метаклассами и полиморфными контейнерами.

Тем не менее, при грамотном применении ООП в C++ достигается та же инкапсуляция, модульность и повторное использование кода, что и в «чисто» объектных языках — но с дополнительными преимуществами: предсказуемой производительностью, отсутствием накладных расходов сборщика мусора и возможностью точной настройки поведения на уровне деталей реализации.

Четыре столпа ООП в C++

C++ реализует классическую четвёрку принципов, лежащих в основе объектно-ориентированного проектирования:

  • Инкапсуляция — объединение данных и методов, работающих с этими данными, внутри одной сущности (класса), и управление доступом к внутреннему состоянию;
  • Наследование — возможность создания новых классов на основе существующих с расширением или изменением их поведения;
  • Полиморфизм — способность объектов различных типов отвечать на один и тот же запрос по-разному, при этом вызов выглядит единообразно;
  • Абстракция — выделение существенных характеристик объекта, скрытие несущественных деталей реализации и представление объекта через его интерфейс.

Эти принципы — проектировочные идеи, которые можно применять даже без использования всех возможностей языка. Например, инкапсуляцию можно достичь и через структуры с закрытыми полями, а полиморфизм — через функциональные объекты или указатели на функции. Однако C++ предоставляет языковые средства, которые делают реализацию этих идей более строгой, безопасной и удобной.

Рассмотрим каждый принцип подробно, начиная с базовой единицы — класса.


Класс

В C++ класс — это пользовательский тип, определяющий форму и поведение объектов. Объявление класса задаёт:

  • набор членов — переменных (поля, member variables) и функций (методы, member functions);
  • правила доступа к этим членам;
  • способы создания и уничтожения объектов этого типа (конструкторы и деструкторы);
  • поведение при копировании, перемещении, сравнении и т.д.

Пример простого класса:

class Person {
private:
std::string name;

public:
Person(std::string n) : name(std::move(n)) {}

void greet() const {
std::cout << "Hello, my name is " << name << '\n';
}
};

Этот код задаёт тип Person, который хранит имя и умеет его представить. Даже в таком лаконичном виде он иллюстрирует ключевые аспекты:

  • Инкапсуляция: поле name объявлено как private, то есть доступно только внутри методов самого класса. Извне его напрямую прочитать или изменить нельзя. Это защищает внутреннее состояние от некорректного использования.
  • Интерфейс: метод greet() объявлён как public, то есть составляет открытый интерфейс класса. Именно через такие методы внешний код взаимодействует с объектом.
  • Конструктор и инициализация: конструктор Person(std::string n) принимает параметр и инициализирует поле name. Использована инициализирующая список (: name(std::move(n))), что предпочтительнее присваивания в теле конструктора, особенно для сложных или дорогостоящих объектов.
  • Константность метода: ключевое слово const после параметров метода означает, что метод не изменяет состояние объекта. Это гарантирует безопасность вызова на const-объектах и позволяет компилятору проводить дополнительные проверки и оптимизации.

Создание экземпляра класса:

Person p("Alice");
p.greet(); // вывод: Hello, my name is Alice

Здесь p — объект типа Person, созданный на стеке. Его время жизни ограничено областью видимости, и при выходе из этой области автоматически вызовется деструктор (если он определён). Такая модель управления ресурсами — основа идиомы RAII, о которой будет сказано отдельно.


Модификаторы доступа

Контроль доступа в C++ осуществляется на уровне секций внутри класса. Это означает, что после ключевого слова private:, protected: или public: все последующие объявления (до следующего модификатора или конца класса) наследуют этот уровень доступа.

Уровень доступаДоступен из…
privateтолько методов того же класса
protectedметодов того же класса и производных классов
publicлюбого кода

Важное отличие от Java и C#: в C++ нет аналога package-private (или internal) уровня. Однако эту роль частично выполняют анонимные пространства имён (namespace { … }) и friend-объявления, которые позволяют открыть доступ к закрытым членам конкретным функциям или классам.

class Sensor {
private:
double raw_value;

protected:
void calibrate() { /* внутренняя логика калибровки */ }

public:
double getValue() const { return raw_value * 0.98; }

friend void diagnosticsTool(const Sensor& s); // разрешаем доступ в диагностике
};

void diagnosticsTool(const Sensor& s) {
std::cout << "Raw: " << s.raw_value << "\n"; // допустимо благодаря friend
}

Использование protected требует осторожности: оно создаёт неявный контракт между базовым и производным классом, который трудно документировать и поддерживать. В современной практике предпочтение отдаётся композиции и интерфейсам, а не расширению через наследование с доступом к защищённым членам.


Наследование

Наследование в C++ позволяет определить новый класс — производный (derived, subclass), — основанный на существующем — базовом (base, superclass). Производный класс наследует все члены базового (кроме конструкторов, деструктора и операторов присваивания), но может:

  • добавлять новые поля и методы;
  • переопределять виртуальные методы (полиморфизм);
  • изменять поведение конструкторов (через вызов конструкторов базового класса);
  • ограничивать или расширять доступ к унаследованным членам.

Синтаксис:

class Student : public Person {
private:
int student_id;

public:
Student(std::string n, int id)
: Person(std::move(n)), // вызов конструктора базового класса
student_id(id) {}

void study() const {
std::cout << name << " is studying...\n"; // ошибка: name — private в Person!
}
};

В этом примере возникнет ошибка компиляции: name объявлен как private в Person, поэтому даже производный класс не имеет к нему доступа. Для решения есть два пути:

  1. Объявить name как protected в Person (не всегда безопасно).
  2. Предоставить protected или public метод-геттер в Person, например:
    protected:
    const std::string& getName() const { return name; }

Тип наследования (public, protected, private) определяет, как изменяется доступ к унаследованным членам внешнего интерфейса:

  • public — наследование «является» (is-a): интерфейс базового класса остаётся доступен как public в производном. Это стандартный случай для полиморфных иерархий.
  • protected — наследование «реализуется через», но скрывает базовый интерфейс от внешнего кода (используется редко).
  • private — наследование «реализуется через» (has-a по форме, но не по смыслу): все public и protected члены базового класса становятся private в производном. Часто применяется для композиции через наследование (например, при реализации CRTP или внутренней делегации).

Для большинства случаев — особенно при использовании полиморфизма — требуется именно public наследование.


Полиморфизм

Полиморфизм — это способность различных объектов реагировать на один и тот же запрос по-разному, сохраняя при этом единообразие интерфейса. В C++ он реализуется преимущественно через виртуальные функции и наследование. Это позволяет писать код, который оперирует абстракциями, не зная конкретных типов реализации на этапе компиляции, — тем самым достигается гибкость, расширяемость и устойчивость к изменениям.

Рассмотрим ключевые элементы механизма.

Виртуальные функции и переопределение

Чтобы функция-член могла быть переопределена в производном классе, она должна быть объявлена как virtual в базовом классе:

class Animal {
public:
virtual void speak() const {
std::cout << "Animal sound\n";
}
};

Ключевое слово virtual указывает компилятору, что вызов этой функции должен разрешаться динамически, во время выполнения, на основе реального типа объекта — а не статически, по типу указателя или ссылки.

Производный класс может переопределить такую функцию, предоставив собственную реализацию:

class Dog : public Animal {
public:
void speak() const override { // override — необязательно, но настоятельно рекомендуется
std::cout << "Woof!\n";
}
};

Здесь override — это спецификатор контекста. Он даёт компилятору возможность проверить, действительно ли функция переопределяет виртуальную из базового класса. Если сигнатура не совпадает — будет ошибка компиляции, что исключает скрытые баги.

Теперь поведение зависит от фактического типа объекта:

Animal* a1 = new Animal();
Animal* a2 = new Dog();

a1->speak(); // Animal sound
a2->speak(); // Woof!

delete a1;
delete a2;

Даже несмотря на то, что оба указателя имеют тип Animal*, во втором случае вызывается Dog::speak(). Это и есть динамическая диспетчеризация.

Таблицы виртуальных функций (vtable)

Каждый класс, содержащий хотя бы одну виртуальную функцию, получает таблицу виртуальных функций — статический массив указателей на реализации виртуальных методов. Каждый объект такого класса содержит скрытый указатель на соответствующую vtable (vptr). При вызове виртуальной функции:

  1. из объекта извлекается vptr;
  2. по индексу функции (определённому на этапе компиляции) из таблицы читается адрес реализации;
  3. вызывается функция по этому адресу.

Это добавляет небольшую накладную стоимость (одно разыменование указателя), но позволяет добиться гибкости, сопоставимой с интерфейсами в Java/C#.

Чисто виртуальные функции и абстрактные классы

Если виртуальная функция не имеет реализации в базовом классе и должна быть обязательно переопределена в производных, она объявляется как чисто виртуальная с помощью синтаксиса = 0:

class Shape {
public:
virtual double area() const = 0; // чисто виртуальная функция
virtual ~Shape() = default;
};

Класс с хотя бы одной чисто виртуальной функцией называется абстрактным. Создать объект абстрактного класса напрямую невозможно:

Shape s;         // ошибка компиляции: нельзя инстанцировать абстрактный класс
Shape* p = nullptr; // OK: указатель — допустим

Абстрактный класс определяет интерфейс или частичную реализацию, которую должны завершить наследники:

class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override {
return 3.14159 * radius * radius;
}
};

Такой подход позволяет строить иерархии, где базовый класс задаёт контракт, а производные — конкретную семантику. Это аналог интерфейсов (interface) в Java/C#, но с возможностью включения реализации по умолчанию (если не все функции чисто виртуальные).

Виртуальный деструктор

Одна из самых критичных и часто упускаемых деталей — деструктор базового класса должен быть виртуальным, если предполагается удалять объекты производных классов через указатель на базовый.

Рассмотрим проблему:

class Base {
public:
~Base() { std::cout << "~Base\n"; }
};

class Derived : public Base {
public:
~Derived() { std::cout << "~Derived\n"; }
};

Base* p = new Derived();
delete p; // Вывод: только ~Base

Поскольку деструктор в Base не виртуальный, вызов delete p приводит к статической диспетчеризации: вызывается только ~Base(), а ~Derived()не вызывается. Это означает, что ресурсы, захваченные в Derived (файлы, память, сокеты), не освобождаются — возникает утечка.

Исправление:

class Base {
public:
virtual ~Base() = default; // или { /* ... */ }
};

Теперь при delete p сначала вызовется ~Derived(), затем ~Base() — и все ресурсы освободятся корректно.

Правило: если класс предназначен для наследования и в нём есть хотя бы одна виртуальная функция, его деструктор должен быть виртуальным. Если класс не предназначен для наследования — наследование можно запретить явно через final, и тогда виртуальность не требуется.

Множественное наследование и виртуальное наследование

В отличие от Java и C#, C++ допускает множественное наследование: класс может наследовать сразу от нескольких базовых:

class Flyable {
public:
virtual void fly() const = 0;
};

class Swimmable {
public:
virtual void swim() const = 0;
};

class Duck : public Animal, public Flyable, public Swimmable {
public:
void speak() const override { std::cout << "Quack!\n"; }
void fly() const override { std::cout << "Duck is flying\n"; }
void swim() const override { std::cout << "Duck is swimming\n"; }
};

Это мощный, но потенциально опасный инструмент. Основная проблема — алмазная проблема (diamond problem), возникающая при наличии общего предка:

class A { public: int x; };
class B : public A {};
class C : public A {};
class D : public B, public C {}; // D содержит два экземпляра A::x!

Чтобы гарантировать один экземпляр общего базового класса, используется виртуальное наследование:

class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {}; // Теперь D содержит один A::x

Виртуальное наследование изменяет схему хранения и инициализации: конструктор A вызывается напрямую из самого производного класса (D), а не через B или C. Это требует внимания при проектировании, но позволяет корректно моделировать сложные иерархии, такие как потоки ввода-вывода (std::iostream наследует и от istream, и от ostream, оба из которых виртуально наследуют ios_base).


Жизненный цикл объекта

Управление ресурсами — одна из центральных задач в C++, особенно в условиях отсутствия сборщика мусора. ООП в C++ тесно связан с идиомой RAII (Resource Acquisition Is Initialization), которая гласит: получение ресурса происходит в конструкторе, а освобождение — в деструкторе.

Это позволяет привязать время жизни ресурса (памяти, файла, мьютекса, сокета) ко времени жизни объекта. Когда объект выходит из области видимости — ресурс автоматически освобождается, даже в случае исключения.

Конструкторы

Конструктор — это специальная функция, вызываемая при создании объекта. Его задача — привести объект в корректное и полностью инициализированное состояние. Важно различать:

  • инициализацию — задание начального значения при создании объекта;
  • присваивание — изменение уже существующего объекта.

Инициализация в конструкторе должна происходить в списке инициализации (member-initializer-list), а не в теле:

class FileHandler {
std::string filename;
FILE* handle;

public:
FileHandler(const std::string& name)
: filename(name), // инициализация std::string
handle(std::fopen(name.c_str(), "r")) // инициализация handle
{
if (!handle)
throw std::runtime_error("Failed to open file: " + name);
// тело конструктора: логика после инициализации
}

~FileHandler() {
if (handle)
std::fclose(handle); // освобождение ресурса
}
};

Почему это важно?

  • Поля инициализируются до входа в тело конструктора — в порядке объявления в классе, а не в порядке записи в списке.
  • Для const-полей и ссылок инициализация обязательна и возможна только в списке.
  • Для объектов классов без конструктора по умолчанию инициализация в списке — единственный способ.
  • Инициализация эффективнее присваивания: при присваивании в теле сначала вызывается конструктор по умолчанию (если есть), затем оператор присваивания — это избыточно.

Деструктор

Деструктор вызывается автоматически, когда объект покидает область видимости (для стека) или при delete (для кучи). Он не принимает параметров и не возвращает значение. Его нельзя вызвать явно (кроме случая placement-new), но можно вызвать неявно через obj.~T() — это редкость и требует осторожности.

Деструктор не наследуется, но вызывается автоматически для всех подобъектов: сначала — деструктор производного класса, затем — базовых (в порядке, обратном конструированию).

Правило пяти (Rule of Five)

Если класс управляет ресурсами (например, владеет указателем, файлом, сокетом), то, вероятно, потребуется определить пять специальных функций:

  1. Деструктор (~T())
  2. Конструктор копирования (T(const T&))
  3. Оператор присваивания копированием (T& operator=(const T&))
  4. Конструктор перемещения (T(T&&)) — C++11
  5. Оператор присваивания перемещением (T& operator=(T&&)) — C++11

Если определена хотя бы одна из этих функций, почти всегда нужно определить и остальные — иначе поведение по умолчанию (член-по-члену копирование указателей) приведёт к двойному освобождению или утечкам.

Пример проблемного кода:

class BadBuffer {
int* data;
size_t size;
public:
BadBuffer(size_t n) : size(n), data(new int[n]) {}
~BadBuffer() { delete[] data; }
// остальные — по умолчанию!
};

BadBuffer a(10);
BadBuffer b = a; // копирование по умолчанию: data скопирован как указатель
// теперь a.data == b.data
// при выходе: сначала ~b() освобождает data, затем ~a() — падение

Исправление — явное определение копирования и перемещения:

class GoodBuffer {
int* data = nullptr;
size_t size = 0;

public:
GoodBuffer(size_t n) : size(n), data(new int[n]) {}

// Конструктор копирования
GoodBuffer(const GoodBuffer& other)
: size(other.size), data(new int[other.size]) {
std::copy(other.data, other.data + size, data);
}

// Оператор присваивания копированием
GoodBuffer& operator=(const GoodBuffer& other) {
if (this != &other) {
delete[] data;
size = other.size;
data = new int[size];
std::copy(other.data, other.data + size, data);
}
return *this;
}

// Конструктор перемещения
GoodBuffer(GoodBuffer&& other) noexcept
: size(other.size), data(other.data) {
other.size = 0;
other.data = nullptr;
}

// Оператор присваивания перемещением
GoodBuffer& operator=(GoodBuffer&& other) noexcept {
if (this != &other) {
delete[] data;
size = other.size;
data = other.data;
other.size = 0;
other.data = nullptr;
}
return *this;
}

~GoodBuffer() { delete[] data; }
};

Однако на практике — особенно после C++11 — часто лучше делегировать управление ресурсами умным указателям или контейнерам и оставить специальные функции по умолчанию (или удалить их с помощью = default / = delete). Это и есть суть современного C++: не пишите RAII-обёртки вручную, если можно использовать готовые.

Правило нуля (Rule of Zero): стремитесь к тому, чтобы в классе не было пользовательских специальных функций. Достигается это использованием членов, которые сами соблюдают RAII (std::string, std::vector, std::unique_ptr и т.д.).


Перегрузка функций и операторов

C++ позволяет определять несколько функций с одинаковым именем, но разными сигнатурами — это перегрузка функций (function overloading). Также допускается перегрузка операторов (operator overloading), что позволяет использовать встроенные символы (+, -, <<, (), [] и др.) для пользовательских типов. Оба механизма направлены на повышение читаемости и естественности кода, но требуют ответственного применения: перегрузка должна сохранять семантическую целостность — пользователь не должен удивляться поведению оператора.

Важно не путать:

  • Перегрузку (overloading) — несколько функций с одним именем, различающихся параметрами (выбор происходит на этапе компиляции).
  • Переопределение (overriding) — замена реализации виртуальной функции в производном классе (выбор — во время выполнения).

Перегрузка функций

Функции считаются перегруженными, если у них одинаковое имя, но различаются:

  • количеством параметров;
  • типами параметров (включая const-квалификацию);
  • порядком параметров.

Возвращаемый тип не участвует в разрешении перегрузки.

Пример:

void log(const char* msg);
void log(const std::string& msg);
void log(int value);
void log(double value, int precision = 2);

Вызов log("Hello") однозначно свяжется с первой версией, log(std::string("Hi")) — со второй, log(42) — с третьей, а log(3.14) — с четвёртой (благодаря параметру по умолчанию).

Компилятор применяет алгоритм разрешения перегрузки (overload resolution), который:

  1. собирает все кандидаты с данным именем в текущей области видимости;
  2. отбрасывает те, чьи параметры нельзя связать с аргументами (например, нет неявного преобразования);
  3. из оставшихся выбирает наилучшее совпадение по числу и «стоимости» преобразований (exact match > promotion > conversion).

Если наилучшее совпадение неоднозначно — ошибка компиляции.

Рекомендация: избегайте избыточной перегрузки. Чем больше вариантов — тем сложнее предсказать, какая функция вызовется. Часто яснее использовать разные имена (parseInt, parseDouble) или параметризацию через шаблоны.

Перегрузка операторов

C++ позволяет перегружать почти все операторы языка — за исключением:

  • . (прямой доступ к члену),
  • .* (доступ через указатель на член),
  • :: (разрешение области видимости),
  • ?: (тернарный условный),
  • sizeof, typeid, alignof, noexcept.

Оператор можно реализовать либо как метод класса, либо как свободную функцию. Выбор влияет на симметрию и доступ к приватным членам.

Операторы как методы класса

Когда оператор реализуется как метод, его левый операнд — это объект (*this), а правый — параметр функции.

class Complex {
double re, im;
public:
Complex(double r = 0, double i = 0) : re(r), im(i) {}

// Оператор += как метод
Complex& operator+=(const Complex& other) {
re += other.re;
im += other.im;
return *this;
}
};

Тогда a += b эквивалентно a.operator+=(b).

Преимущества:

  • прямой доступ ко всем членам класса (включая private);
  • гарантия, что левый операнд — именно объект данного типа.

Недостатки:

  • асимметрия: Complex(1, 2) + 3.0 — возможно, а 3.0 + Complex(1, 2) — нет, если operator+ метод.
Операторы как свободные функции

Для бинарных операторов, где важна симметрия (например, +, ==, <<), предпочтительнее свободная функция:

Complex operator+(Complex a, const Complex& b) {
a += b;
return a;
}

Теперь оба выражения допустимы:

Complex c1(1, 2), c2(3, 4);
Complex c3 = c1 + c2; // OK
Complex c4 = Complex(1, 0) + 5.0; // OK: неявное приведение 5.0 → Complex(5, 0)

Для доступа к private-членам свободная функция объявляется как friend:

class Complex {
double re, im;
public:
// ...
friend Complex operator+(Complex a, const Complex& b);
};

Complex operator+(Complex a, const Complex& b) {
a.re += b.re;
a.im += b.im;
return a;
}
Типичные шаблоны перегрузки

Ниже — проверенные практикой идиомы для часто перегружаемых операторов.

1. Арифметические операторы (+, -, *, /)
Реализуются как свободные функции, использующие составные присваивания (+=, -= и т.д.) как базу:

Complex& operator+=(Complex& lhs, const Complex& rhs) {
lhs.re += rhs.re;
lhs.im += rhs.im;
return lhs;
}

Complex operator+(Complex lhs, const Complex& rhs) {
lhs += rhs;
return lhs; // возврат по значению — возможен move
}

Обратите внимание: левый параметр в operator+ передаётся по значению — это позволяет использовать move-семантику при возврате, если lhs — временный объект.

2. Операторы сравнения (==, !=, <, <=, >, >=)
Согласно C++20, достаточно реализовать <=> (three-way comparison), и компилятор сгенерирует остальные. Но для обратной совместимости часто пишут вручную:

bool operator==(const Complex& a, const Complex& b) {
return a.re == b.re && a.im == b.im;
}

bool operator!=(const Complex& a, const Complex& b) {
return !(a == b);
}

3. Операторы ввода-вывода (<<, >>)
operator<< для std::ostream (например, std::cout) — всегда свободная функция, возвращающая ссылку на поток:

std::ostream& operator<<(std::ostream& os, const Complex& c) {
os << "(" << c.re << "," << c.im << ")";
return os;
}

Это позволяет строить цепочки: cout << c1 << " and " << c2;.

4. Функциональный вызов operator()
Превращает объект в функциональный объект (functor). Широко используется в алгоритмах STL и для замыканий до появления лямбд:

class Multiplier {
int factor;
public:
Multiplier(int f) : factor(f) {}
int operator()(int x) const { return x * factor; }
};

Multiplier dbl(2);
int result = dbl(5); // 10

5. Операторы индексации operator[] и вызова operator()
Для контейнероподобных типов:

class SafeVector {
std::vector<int> data;
public:
int& operator[](size_t i) {
if (i >= data.size())
throw std::out_of_range("Index out of bounds");
return data[i];
}
const int& operator[](size_t i) const { /* ... */ } // перегрузка для const-объектов
};

Обязательно предоставляйте и const, и non-const версии, чтобы поддерживать работу с константными объектами.

6. Операторы new и delete
Можно перегружать как глобальные, так и члены класса, чтобы контролировать размещение объектов (пулы памяти, выравнивание, логирование):

class PooledObject {
public:
void* operator new(size_t size) {
std::cout << "Allocating " << size << " bytes\n";
return ::operator new(size); // делегирование глобальному new
}

void operator delete(void* ptr) noexcept {
std::cout << "Deallocating\n";
::operator delete(ptr);
}
};

Важно: перегрузка new/delete — продвинутая техника. Её следует применять только при наличии чёткой потребности (например, в embedded-системах или высоконагруженных серверах). В большинстве случаев лучше использовать аллокаторы (std::allocator_traits) или умные указатели с кастомными deleter’ами.

Когда НЕ стоит перегружать операторы

Перегрузка уместна, только если:

  • семантика оператора интуитивно понятна;
  • оператор сохраняет естественные свойства (например, + — коммутативен, = — присваивает);
  • не нарушается принцип наименьшего удивления.

Неприемлемые примеры:

Point operator+(const Point& p, int offset);  // что делает? сдвиг по X? по Y? по обеим?
// Лучше: p.offsetX(offset) или p.translate(offset, 0)

Logger& operator<<(Logger& log, const std::string& msg); // OK
Logger& operator<<(Logger& log, int code); // OK, если code — уровень
log << "Error" << 404; // но что означает 404? enum лучше.

// Категорически избегайте:
bool operator&&(const MyType& a, const MyType& b); // нарушает short-circuit!

Операторы &&, ||, , теряют встроенное поведение (ленивые вычисления, порядок), если перегружены — это почти всегда ошибка.


Управление памятью

В C++ нет сборщика мусора, но это не означает, что управление памятью должно быть болезненным. Наоборот: язык предоставляет инструменты для предсказуемого, детерминированного и безопасного управления ресурсами — при условии следования идиомам.

Стек против кучи: где живут объекты

  • Стек — область памяти с автоматическим управлением временем жизни. Объекты создаются при входе в блок, уничтожаются при выходе (LIFO). Быстро, без фрагментации, без утечек.

    {
    std::vector<int> v(1000); // память под данные — в куче, но сам объект v — на стеке
    } // ~vector() вызывается автоматически → освобождает внутренний буфер
  • Куча — динамически выделяемая память. Управление — в руках программиста: new/delete, malloc/free. Используется, когда:

    • размер неизвестен на этапе компиляции;
    • объект должен «пережить» область видимости;
    • требуется полиморфное хранение (Base* p = new Derived()).

Но: не используйте new напрямую, если нет веской причины.

Умные указатели

Начиная с C++11, стандартная библиотека предоставляет три основных умных указателя, реализующих RAII для динамически выделенной памяти:

УказательСемантика владенияПодсчёт ссылокКогда использовать
std::unique_ptr<T>эксклюзивное владениенетпо умолчанию для единственного владельца
std::shared_ptr<T>совместное владениеда (атомарный)когда жизненный цикл неочевиден, циклические структуры
std::weak_ptr<T>ненаблюдаемая ссылканет (но ссылается на shared-состояние)для разрыва циклов в shared-структурах
std::unique_ptr: безопасная замена «голого» указателя
auto p = std::make_unique<int>(42);
// эквивалентно: std::unique_ptr<int> p(new int(42));
// но make_unique гарантирует исключение-безопасность (no leak при исключении в конструкторе)

// p владеет объектом. При выходе из области — delete вызывается автоматически.

// Передача владения:
auto q = std::move(p); // p становится nullptr, q — владелец

unique_ptr не копируемый, но перемещаемый — это отражает семантику эксклюзивного владения. Он имеет нулевую накладную стоимость: в рантайме — просто указатель.

std::shared_ptr: когда нужна разделяемая собственность
auto p1 = std::make_shared<Widget>();
auto p2 = p1; // счётчик ссылок = 2

// Объект удаляется, когда последний shared_ptr выходит из области видимости.

make_shared предпочтительнее make_unique(new T) — он выделяет память одним блоком для объекта и управляющего блока (control block), что уменьшает фрагментацию и ускоряет работу.

Циклические ссылки и std::weak_ptr
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // weak — чтобы не создавать цикл
};

auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->prev = a; // если бы было shared_ptr — утечка: счётчики никогда не опустятся до 0

weak_ptr не увеличивает счётчик владения. Чтобы получить shared_ptr из weak_ptr, нужно вызвать lock():

if (auto sp = b->prev.lock()) {
// sp — shared_ptr, объект жив
} else {
// объект уже удалён
}

Правила работы с памятью в современном C++

  1. По умолчанию — стек. Используйте локальные объекты, возвращайте по значению (NRVO/RVO + move делают это эффективным).
  2. Для кучи — make_unique / make_shared. Избегайте прямого new.
  3. Никогда не смешивайте new/delete с malloc/free. Деструкторы не вызовутся при free.
  4. Не храните «голые» указатели, владеющие ресурсом. Если указатель в классе — он должен быть unique_ptr или shared_ptr.
  5. Используйте контейнеры (vector, string) — они уже реализуют RAII для своих данных.

Такой подход позволяет писать код, в котором утечки памяти невозможны по конструкции — даже в условиях исключений.


Шаблоны и обобщённое программирование

Шаблоны — одна из самых мощных и характерных черт C++. Они позволяют писать алгоритмы и структуры данных, не привязанные к конкретному типу, — и при этом сохранять полную эффективность и типовую безопасность. В отличие от generics в Java или C#, шаблоны C++ — это метапрограммирование на этапе компиляции: для каждого набора аргументов шаблона компилятор генерирует отдельную, конкретную версию кода.

Это даёт два ключевых преимущества:

  1. Нулевая стоимость абстракции — сгенерированный код ничем не отличается от написанного вручную для конкретного типа.
  2. Максимальная гибкость — шаблоны могут работать с любыми типами, удовлетворяющими структурным требованиям (например, «умеет operator<»), а не только с наследниками общего интерфейса.

Но есть и цена: ошибки в шаблонах часто проявляются как многострочные, малопонятные сообщения компилятора, а размер исполняемого файла может расти из-за множественных инстанцирований.

Функции-шаблоны

Самая простая форма — шаблонная функция:

template<typename T>
T max(T a, T b) {
return (a > b) ? a : b;
}

Здесь typename T — параметр шаблона. Слово typename можно заменить на class — это синонимы в данном контексте.

При вызове:

int i = max(3, 5);          // T = int → генерируется int max(int, int)
double d = max(1.2, 3.4); // T = double → double max(double, double)

Компилятор выводит тип T из аргументов. Вывод возможен, только если все экземпляры параметра в сигнатуре могут быть однозначно сопоставлены. Если нет — нужно указать явно:

auto result = max<int>(3, 5.0);  // приведение 5.0 → int, затем сравнение

Классы-шаблоны

Шаблонный класс определяет целое семейство типов:

template<typename T>
class Stack {
std::vector<T> data;
public:
void push(const T& value) { data.push_back(value); }
T pop() {
if (data.empty()) throw std::runtime_error("Empty stack");
T val = std::move(data.back());
data.pop_back();
return val;
}
bool empty() const { return data.empty(); }
};

Использование:

Stack<int> int_stack;
Stack<std::string> str_stack;

Каждое из этих объявлений приводит к генерации независимого класса с заменой T на соответствующий тип. В памяти объекты int_stack и str_stack — совершенно разные типы: Stack<int> и Stack<std::string> не имеют общего предка и не могут быть приведены друг к другу.

Это принципиальное отличие от Java/C#, где List<Integer> и List<String> — это один и тот же класс List с разными generic-аргументами, а информация о типе стирается в рантайме (type erasure). В C++ — нет стирания типов. Информация о T сохраняется полностью, что позволяет:

  • вызывать методы, специфичные для T, без приведения;
  • специализировать поведение для отдельных типов;
  • использовать шаблоны в качестве основы для метапрограммирования.

Специализации

Иногда требуется изменить поведение шаблона для определённого типа. Это делается через специализацию.

Полная специализация
// Общая версия
template<typename T>
struct Printer {
static void print(const T& x) {
std::cout << x << '\n';
}
};

// Специализация для bool
template<>
struct Printer<bool> {
static void print(bool x) {
std::cout << (x ? "true" : "false") << '\n';
}
};

Printer<int>::print(42); // 42
Printer<bool>::print(true); // true

Специализация — это отдельное определение, которое компилятор выбирает, если аргумент шаблона точно совпадает.

Частичная специализация (только для классов)

Позволяет зафиксировать часть параметров, оставив другие общими:

template<typename T, typename Allocator = std::allocator<T>>
class Vector {
// общая реализация
};

// Частичная специализация для bool — как в std::vector<bool>
template<typename Allocator>
class Vector<bool, Allocator> {
// компактное битовое представление
};

Частичная специализация недоступна для функций — вместо неё используют перегрузку или SFINAE.

SFINAE: «Substitution Failure Is Not An Error»

SFINAE — фундаментальный принцип разрешения перегрузки шаблонов: если при подстановке аргументов в сигнатуру шаблонной функции возникает ошибка, эта специализация просто отбрасывается из множества кандидатов, а не приводит к ошибке компиляции.

Это позволяет писать условные шаблоны — например, функцию, которая компилируется только для типов, имеющих определённый метод или оператор.

Пример с std::enable_if (до C++20):

#include <type_traits>

// Вызывается, только если T — целочисленный тип
template<typename T>
typename std::enable_if<std::is_integral_v<T>, T>::type
abs(T x) {
return x < 0 ? -x : x;
}

// Вызывается для остальных типов (например, float, double)
template<typename T>
typename std::enable_if<!std::is_integral_v<T>, T>::type
abs(T x) {
return x < 0 ? -x : x;
}

Здесь std::enable_if<Cond, T>::type определён, только если Cond == true. Если условие ложно — тип не существует → подстановка не удаётся → кандидат отбрасывается → выбирается другая перегрузка.

SFINAE — мощный, но сложный инструмент. Ошибки в нём трудны для диагностики.

Концепции (C++20): читаемость вместо SFINAE

C++20 вводит концепции (concepts) — именованные ограничения на параметры шаблонов. Это делает код на порядки понятнее:

#include <concepts>

template<std::integral T>
T abs(T x) {
return x < 0 ? -x : x;
}

template<std::floating_point T>
T abs(T x) {
return x < 0 ? -x : x;
}

// Или обобщённо:
template<typename T>
requires std::integral<T> || std::floating_point<T>
T abs(T x) { /* ... */ }

Концепции проверяются на этапе компиляции и дают ясные сообщения об ошибках:
«abs не определён для std::string, потому что std::string не удовлетворяет std::integral» — вместо страницы шаблонного бек-трейса.

Можно определять свои концепции:

template<typename T>
concept Printable = requires(T x) {
{ std::cout << x } -> std::same_as<std::ostream&>;
};

template<Printable T>
void log(const T& x) {
std::cout << x << '\n';
}

Требование requires проверяет, что выражение std::cout << x корректно и возвращает std::ostream&.

Шаблоны против generics: ключевые различия

КритерийC++ (шаблоны)Java / C# (generics)
Время связываниякомпиляция (код генерируется для каждого T)выполнение (стирание типов / реификация)
Производительностьнулевая накладная стоимостьвозможны боксинг/анбоксинг (Java), виртуальные вызовы
Гибкостьработает с любыми типами, удовлетворяющими структуретолько ссылочные типы (Java), примитивы через обёртки
Ошибкина этапе компиляции, но сложныена этапе компиляции, более понятные
Метапрограммированиеполноценное (constexpr, SFINAE, концепции)ограничено
Размер кодаможет расти (код дублируется)один общий байткод / IL

Пример: в Java невозможно написать List<int> — только List<Integer>. В C++ std::vector<int> — это эффективный контейнер из int напрямую, без обёрток.

Практические рекомендации по использованию шаблонов

  1. Начинайте с конкретного кода. Не делайте всё шаблонным «на всякий случай». Обобщайте, когда появляется повторяющаяся структура для разных типов.
  2. Используйте auto и шаблонные лямбды — они скрывают шаблонную сложность от пользователя:
    auto add = [](const auto& a, const auto& b) { return a + b; };
    Такая лямбда — это шаблонная функция, но синтаксис проще.
  3. Предпочитайте std::vector<T>, std::function<R(Args...)> и другие стандартные шаблоны — они протестированы и оптимизированы.
  4. Избегайте глубокой рекурсии шаблонов — это замедляет компиляцию и усложняет отладку.
  5. Документируйте требования к типам — даже без концепций:
    «Тип T должен поддерживать:
    • копирование (или перемещение);
    • operator< для сортировки;
    • конструктор по умолчанию (если используется resize).»

Стандартная библиотека шаблонов (STL)

STL — целостная архитектура, построенная на трёх взаимосвязанных компонентах:

  1. Контейнеры (vector, list, map, set, …) — хранят данные.
  2. Итераторы — абстрагируют доступ к элементам контейнера («умные указатели»).
  3. Алгоритмы (sort, find, transform, accumulate, …) — работают через итераторы.

Эта декомпозиция позволяет комбинировать любые алгоритмы с любыми контейнерами — при условии, что контейнер предоставляет итераторы нужной категории.

Контейнеры

Неправильно выбирать контейнер «самый быстрый». Правильно — выбрать тот, чья семантика соответствует задаче.

КонтейнерСемантикаОсобенности
std::vector<T>динамический массивнепрерывная память, произвольный доступ, амортизированно O(1) вставка в конец
std::deque<T>двусторонняя очередьпроизвольный доступ, O(1) вставка/удаление в начале и конце
std::list<T>двусвязный списокO(1) вставка/удаление в любом месте при наличии итератора, нет произвольного доступа
std::forward_list<T>односвязный списокминимальные накладные расходы, только forward-итераторы
std::set<T> / std::map<K,V>упорядоченное множество / отображениедерево (обычно красно-чёрное), элементы отсортированы, O(log n) вставка/поиск
std::unordered_set<T> / std::unordered_map<K,V>хеш-таблицасредний O(1) поиск/вставка, порядок не гарантируется
std::array<T, N>фиксированный массивстековый, без динамического выделения, поддержка begin/end

Пример выбора:

  • Нужен произвольный доступ и компактность? → vector.
  • Частые вставки в начало? → deque (не list — он медленнее из-за аллокаций узлов).
  • Гарантированная сортировка и уникальность? → set.
  • Быстрый поиск по ключу, порядок неважен? → unordered_map.

Итераторы

Итератор — это объект, ведущий себя как указатель: поддерживает *, ->, ++, иногда --, +, -, [].

Категории итераторов (от слабых к сильным):

  • Input / Output — однократное чтение / запись (потоки).
  • Forward — многократное чтение, только вперёд (forward_list).
  • Bidirectional — вперёд и назад (list, set).
  • Random Access — произвольный доступ за O(1) (vector, deque, array).
  • Contiguous (C++17) — элементы в непрерывной памяти (vector, array).

Алгоритмы требуют минимум определённую категорию:

std::sort(v.begin(), v.end());  // требует random-access итераторы
std::list<int> lst;
// std::sort(lst.begin(), lst.end()); // ошибка: list — bidirectional, не random-access
lst.sort(); // но у list есть свой метод sort()

Алгоритмы

STL предоставляет более 100 алгоритмов, охватывающих:

  • Немодифицирующие (find, count, equal, for_each)
  • Модифицирующие (copy, transform, replace, fill)
  • Упорядочение (sort, partial_sort, nth_element)
  • Поиск по условию (lower_bound, upper_bound, equal_range)
  • Численные (accumulate, inner_product, iota)

Пример: замена цикла на алгоритм:

// Плохо: ручной цикл
int sum = 0;
for (int x : v) {
if (x > 0) sum += x;
}

// Лучше: алгоритмы
auto positive = [](int x) { return x > 0; };
int sum = std::accumulate(
std::begin(v), std::end(v), 0,
[&](int acc, int x) { return acc + (positive(x) ? x : 0); }
);

// Или: сначала фильтрация (C++20 ranges)
#include <ranges>
auto sum = v | std::views::filter(positive)
| std::views::transform([](int x) { return x; })
| std::ranges::accumulate(0);

Алгоритмы:

  • чище выразительно;
  • менее подвержены ошибкам (например, off-by-one);
  • поддаются оптимизации (например, std::copy может использовать memmove для POD-типов);
  • позволяют легко распараллеливать (через std::execution::par).

Адаптеры контейнеров: stack, queue, priority_queue

Это обёртки над другими контейнерами (по умолчанию — deque), предоставляющие ограниченный интерфейс:

std::stack<int> s;  // использует deque<int> внутри
s.push(1);
s.push(2);
std::cout << s.top(); // 2
s.pop();

// Можно указать базовый контейнер:
std::stack<int, std::vector<int>> s2; // на основе vector

priority_queue — это бинарная куча (std::make_heap, std::push_heap, std::pop_heap под капотом).